1. 挂载子节点与元素属性

1.1 子节点的挂载

虚拟 DOM 的核心是虚拟节点(vnode),它描述了 DOM 树的结构。一个元素可能包含文本子节点或其他元素子节点,vnode 的 children 属性可以是字符串(表示文本内容)或数组(表示多个子节点)。例如:

const vnode = {
  type: 'div',
  children: [
    { type: 'p', children: 'hello' }
  ]
}

为了挂载子节点,我们需要在 mountElement 函数中处理 children 的不同类型:

function mountElement(vnode, container) {
  const el = document.createElement(vnode.type)
  if (typeof vnode.children === 'string') {
    el.textContent = vnode.children
  } else if (Array.isArray(vnode.children)) {
    vnode.children.forEach(child => {
      patch(null, child, el)
    })
  }
  container.appendChild(el)
}

这里,patch 函数递归调用 mountElement 来挂载子节点,null 表示没有旧节点(初次挂载),el 作为挂载点确保子节点挂载到正确位置。

1.2 元素属性的处理

元素属性通过 vnode.props 定义,例如:

const vnode = {
  type: 'div',
  props: { id: 'foo' },
  children: 'hello'
}

mountElement 中,我们遍历 props 并设置属性:

function mountElement(vnode, container) {
  const el = document.createElement(vnode.type)
  // 处理子节点(省略)
  if (vnode.props) {
    for (const key in vnode.props) {
      el.setAttribute(key, vnode.props[key])
    }
  }
  container.appendChild(el)
}

但直接使用 setAttribute 并不总是最佳选择,因为 HTML Attributes 和 DOM Properties 之间存在差异。

2. HTML Attributes 与 DOM Properties

2.1 两者的区别

HTML Attributes 是写在 HTML 标签上的属性,如 <input id="my-input" value="foo"> 中的 idvalue。浏览器解析后会生成 DOM 对象,DOM Properties 是这些对象的属性,如 el.idel.value

两者的关系复杂,主要体现在:

  • 映射关系:某些 Attributes 直接映射到 Properties(如 id),但也有例外(如 class 对应 el.className)。
  • 初始值与当前值:Attributes 设置 Properties 的初始值,Properties 反映当前值。例如,用户修改 <input> 的值后,el.value 变为新值,但 el.getAttribute('value') 仍返回初始值。
  • 只读属性:如 <input form="form1">form 属性,el.form 是只读的,只能通过 setAttribute 设置。

核心原则:HTML Attributes 用于设置 DOM Properties 的初始值

2.2 正确设置属性

为了正确设置属性,我们需要区分属性是否对应 DOM Properties,并处理特殊情况(如布尔属性)。以下是改进的实现:

function shouldSetAsProps(el, key, value) {
  if (key === 'form' && el.tagName === 'INPUT') return false
  return key in el
}

function mountElement(vnode, container) {
  const el = document.createElement(vnode.type)
  if (vnode.props) {
    for (const key in vnode.props) {
      const value = vnode.props[key]
      if (shouldSetAsProps(el, key, value)) {
        const type = typeof el[key]
        if (type === 'boolean' && value === '') {
          el[key] = true
        } else {
          el[key] = value
        }
      } else {
        el.setAttribute(key, value)
      }
    }
  }
  // 处理子节点(省略)
  container.appendChild(el)
}

这里,shouldSetAsProps 判断属性是否应作为 DOM Properties 设置,并对布尔属性(如 disabled)做特殊处理,确保空字符串被矫正为 true

3. 特殊属性的处理:以 class 为例

Vue.js 增强了 class 属性的处理,支持字符串、对象和数组形式:

const vnode = {
  type: 'p',
  props: {
    class: ['foo bar', { baz: true }]
  }
}

需要将不同类型的 class 值归一化为字符串:

function normalizeClass(value) {
  let res = ''
  if (typeof value === 'string') {
    res = value
  } else if (Array.isArray(value)) {
    value.forEach(item => {
      res += normalizeClass(item) + ' '
    })
  } else if (typeof value === 'object') {
    for (const key in value) {
      if (value[key]) res += key + ' '
    }
  }
  return res.trim()
}

patchProps 中优先使用 el.className 设置 class,因为其性能优于 setAttributeel.classList

function patchProps(el, key, prevValue, nextValue) {
  if (key === 'class') {
    el.className = nextValue || ''
  } else if (shouldSetAsProps(el, key, nextValue)) {
    const type = typeof el[key]
    if (type === 'boolean' && nextValue === '') {
      el[key] = true
    } else {
      el[key] = nextValue
    }
  } else {
    el.setAttribute(key, nextValue)
  }
}

style 属性也需要类似处理,涉及值归一化和性能优化。

4. 卸载操作

卸载操作在更新阶段触发,例如渲染 null

renderer.render(null, container)

直接使用 container.innerHTML = '' 不够严谨,因为:

  • 可能遗漏组件的生命周期钩子(如 beforeUnmount)。
  • 无法触发自定义指令的钩子。
  • 不会移除绑定的事件处理函数。

正确的做法是记录 vnode.el 与真实 DOM 的关联,并在卸载时移除具体元素:

function unmount(vnode) {
  const parent = vnode.el.parentNode
  if (parent) parent.removeChild(vnode.el)
}

function render(vnode, container) {
  if (vnode) {
    patch(container._vnode, vnode, container)
  } else if (container._vnode) {
    unmount(container._vnode)
  }
  container._vnode = vnode
}

5. 区分 vnode 类型

vnode 的 type 属性决定其类型:

  • 字符串:普通标签(如 'div')。
  • 对象:组件。
  • Symbol:特殊节点(如文本、注释、Fragment)。

patch 函数中根据类型调用不同逻辑:

function patch(n1, n2, container) {
  if (n1 && n1.type !== n2.type) {
    unmount(n1)
    n1 = null
  }
  const { type } = n2
  if (typeof type === 'string') {
    if (!n1) {
      mountElement(n2, container)
    } else {
      patchElement(n1, n2)
    }
  } else if (type === Text) {
    // 处理文本节点(后文详述)
  } else if (type === Fragment) {
    // 处理 Fragment(后文详述)
  }
}

6. 事件处理

6.1 事件描述与绑定

事件通过 vnode.props 中以 on 开头的属性描述:

const vnode = {
  type: 'p',
  props: { onClick: () => alert('clicked') }
}

patchProps 中绑定事件:

function patchProps(el, key, prevValue, nextValue) {
  if (/^on/.test(key)) {
    const name = key.slice(2).toLowerCase()
    el.addEventListener(name, nextValue)
  } else {
    // 属性处理(省略)
  }
}

6.2 事件更新优化

为避免重复调用 removeEventListener,使用伪造事件处理函数 invoker

function patchProps(el, key, prevValue, nextValue) {
  if (/^on/.test(key)) {
    const invokers = el._vei || (el._vei = {})
    const name = key.slice(2).toLowerCase()
    let invoker = invokers[key]
    if (nextValue) {
      if (!invoker) {
        invoker = el._vei[key] = (e) => {
          if (Array.isArray(invoker.value)) {
            invoker.value.forEach(fn => fn(e))
          } else {
            invoker.value(e)
          }
        }
        invoker.value = nextValue
        el.addEventListener(name, invoker)
      } else {
        invoker.value = nextValue
      }
    } else if (invoker) {
      el.removeEventListener(name, invoker)
    }
  } else {
    // 属性处理(省略)
  }
}

支持同一事件绑定多个处理函数(如 onClick: [fn1, fn2])。

6.3 事件冒泡与更新时机

事件冒泡可能导致意外触发。例如,子节点事件触发响应式数据更新,导致父节点绑定新事件,而冒泡又触发了新事件。解决方案是比较事件触发时间与绑定时间:

function patchProps(el, key, prevValue, nextValue) {
  if (/^on/.test(key)) {
    const invokers = el._vei || (el._vei = {})
    const name = key.slice(2).toLowerCase()
    let invoker = invokers[key]
    if (nextValue) {
      if (!invoker) {
        invoker = el._vei[key] = (e) => {
          if (e.timeStamp < invoker.attached) return
          if (Array.isArray(invoker.value)) {
            invoker.value.forEach(fn => fn(e))
          } else {
            invoker.value(e)
          }
        }
        invoker.value = nextValue
        invoker.attached = performance.now()
        el.addEventListener(name, invoker)
      } else {
        invoker.value = nextValue
      }
    } else if (invoker) {
      el.removeEventListener(name, invoker)
    }
  } else {
    // 属性处理(省略)
  }
}

7. 子节点更新

子节点更新涉及 vnode.children 的三种情况:null、字符串、数组。新旧子节点组合有九种可能,但代码中只需处理关键场景:

function patchChildren(n1, n2, container) {
  if (typeof n2.children === 'string') {
    if (Array.isArray(n1.children)) {
      n1.children.forEach(c => unmount(c))
    }
    container.textContent = n2.children
  } else if (Array.isArray(n2.children)) {
    if (Array.isArray(n1.children)) {
      n1.children.forEach(c => unmount(c))
      n2.children.forEach(c => patch(null, c, container))
    } else {
      container.textContent = ''
      n2.children.forEach(c => patch(null, c, container))
    }
  } else {
    if (Array.isArray(n1.children)) {
      n1.children.forEach(c => unmount(c))
    } else if (typeof n1.children === 'string') {
      container.textContent = ''
    }
  }
}

后续章节会介绍 Diff 算法优化数组子节点的更新。

8. 文本节点与注释节点

文本节点和注释节点使用 Symbol 标识:

const Text = Symbol()
const Comment = Symbol()

const textVNode = { type: Text, children: '文本内容' }
const commentVNode = { type: Comment, children: '注释内容' }

patch 函数中处理:

function patch(n1, n2, container) {
  if (n1 && n1.type !== n2.type) {
    unmount(n1)
    n1 = null
  }
  const { type } = n2
  if (type === Text) {
    if (!n1) {
      const el = n2.el = document.createTextNode(n2.children)
      container.appendChild(el)
    } else {
      const el = n2.el = n1.el
      if (n2.children !== n1.children) {
        el.nodeValue = n2.children
      }
    }
  }
  // 其他类型处理(省略)
}

为跨平台,封装 createTextsetText

const renderer = createRenderer({
  createText(text) {
    return document.createTextNode(text)
  },
  setText(el, text) {
    el.nodeValue = text
  }
})

9. Fragment

Fragment 允许组件返回多个根节点:

const Fragment = Symbol()
const vnode = {
  type: Fragment,
  children: [
    { type: 'li', children: '1' },
    { type: 'li', children: '2' }
  ]
}

patch 中只渲染子节点:

function patch(n1, n2, container) {
  if (type === Fragment) {
    if (!n1) {
      n2.children.forEach(c => patch(null, c, container))
    } else {
      patchChildren(n1, n2, container)
    }
  }
  // 其他类型处理(省略)
}

卸载 Fragment 时逐个卸载子节点:

function unmount(vnode) {
  if (vnode.type === Fragment) {
    vnode.children.forEach(c => unmount(c))
    return
  }
  const parent = vnode.el.parentNode
  if (parent) parent.removeChild(vnode.el)
}